To be able to edit code and run cells, you need to run the notebook yourself. Where would you like to run the notebook?

This notebook takes about 4 minutes to run.

In the cloud (experimental)

Binder is a free, open source service that runs scientific notebooks in the cloud! It will take a while, usually 2-7 minutes to get a session.

On your computer

(Recommended if you want to store your changes.)

  1. Download the notebook:
  2. Run Pluto

    (Also see: How to install Julia and Pluto)

  3. Open the notebook file

    Type the saved filename in the open box.

Frontmatter

If you are publishing this notebook on the web, you can set the parameters below to provide HTML metadata. This is useful for search engines and social media.

Author 1

Introduction to Explicit Neutral Models

This notebook illustrates how you can use PMD to optimize network models with an explicit neutral (EN) conductor representation. It will go through the full workflow, consisting of

  • importing OpenDSS network data (and applying transformations as needed);

  • adding OPF-specific data to the model;

  • optimizing the model;

  • inspecting the results.

👀 Reading hidden code
md"""
# Introduction to Explicit Neutral Models

This notebook illustrates how you can use PMD to optimize network models with an explicit neutral (EN) conductor representation. It will go through the full workflow, consisting of
- importing OpenDSS network data (and applying transformations as needed);
- adding OPF-specific data to the model;
- optimizing the model;
- inspecting the results.
"""
19.4 ms

This notebook will make use of the following packages in various places

👀 Reading hidden code
md"""
This notebook will make use of the following packages in various places
"""
245 μs
👀 Reading hidden code
begin
using PowerModelsDistribution
import Ipopt
import DataFrames: DataFrame
end
2.7 s
"/home/runner/.julia/packages/PowerModelsDistribution/Pxoxg/src/.."
pmd_path = joinpath(dirname(pathof(PowerModelsDistribution)), "..")
👀 Reading hidden code
51.4 μs

Building a case

Importing network data

👀 Reading hidden code
13.2 ms

OpenDSS cases with explicit neutral conductors tend to use components in a way which is not supported directly by PMD. These cases model the grounding as a 'reactor', connected between different terminals of the same bus (i.e. from the 4th terminal to ground).

In PMD, a reactor is mapped by default to a line, which will then be connected on both ends to the same bus, which is not allowed. This problem is solved by applying the data model transformation transform_loops!, which will map the reactor to a shunt instead, or merge terminals if the reactor is in fact a short-circuit.

👀 Reading hidden code
355 μs
begin
case_path = pmd_path*"/test/data/en_validation_case_data/test_grounding.dss"
data_eng = parse_file(case_path, transformations=[transform_loops!])
end
👀 Reading hidden code
Circuit has been reset with the 'clear' on line 3 in 'test_grounding.dss'
Command 'calcvoltagebases' on line 24 in 'test_grounding.dss' is not supported, skipping.
Command 'solve' on line 26 in 'test_grounding.dss' is not supported, skipping.
reactors as constant impedance elements is not yet supported, treating reactor.grounding like line
12.6 s

Note that this test case is very unbalanced. To obtain a more realistic scenario, we reduce the loading by a factor 3.

👀 Reading hidden code
14.6 ms
begin
for (_,load) in data_eng["load"]
load["pd_nom"] *= 1/3
load["qd_nom"] *= 1/3
end
end
👀 Reading hidden code
53.3 μs

Adding OPF specific data

First of all, it is a good practice to remove any bounds that may have been imported from the OpenDSS network data. These can be default bounds, which might not make sense for the specific case and can lead to infeasibility. For example, this occurs for IEEE13, where the default OpenDSS line ratings are too tight for the base case power flows.

👀 Reading hidden code
306 μs
remove_all_bounds!(data_eng)
👀 Reading hidden code
51.0 ms

Voltage bounds

Voltage bounds are naturally expressed between two terminals.

For example, if a wye-connected load is connected between phase a and the neutral n, we want to apply the bounds lb <= |Ua-Un| <= ub, in order to ensure the voltage across the load does not get too low or high. If the neutral is not modeled and assumed to be grounded everywhere, i.e. Un=0, then this can be done equivalently by bounding Ua directtly.

PMD comes with several data model transformations which allow you to quickly apply voltage bounds. The bounds are specified in per unit, relative to the local voltage base (phase-to-ground). If this was not the case, it would be difficult to specify bounds for multiple voltage zones at once.

First consider add_bus_absolute_vbounds!, which allows you to specify absolute bounds, i.e. for each terminal individually. This will set the bus properties vm_lb and vm_ub appropriately.

👀 Reading hidden code
79.2 ms
add_bus_absolute_vbounds!(
data_eng,
phase_lb_pu = 0.8,
phase_ub_pu = 1.2,
neutral_ub_pu = 0.1,
)
👀 Reading hidden code
2.0 s

The ENGINEERING data model includes special properties to easily apply symmetrical bounds for three-phase buses (refer to the documentation for more details). These properties can be populated easily with add_bus_pn_pp_ng_vbounds!, which specifies

  • phase-to-neutral (pn)

  • phase-to-phase (pp)

  • neutral-to-ground (ng)

bounds. Note that since the voltage base is in phase-to-ground, the pp bounds have to multiplied by sqrt(3) for a three-phase network.

👀 Reading hidden code
691 μs
add_bus_pn_pp_ng_vbounds!(
data_eng, [1:3...], 4,
pn_lb_pu = 0.9,
pn_ub_pu = 1.1,
pp_lb_pu = 0.9*sqrt(3),
pp_ub_pu = 1.1*sqrt(3),
ng_ub_pu = 0.1
)
👀 Reading hidden code
24.5 ms

Often, the true goal of voltage bounds is to protect the connected 'units', i.e. loads, generators etc. Therefore, it can suffice to apply voltage bounds only to those buses and terminals which actually have a unit connected to them. This is what add_unit_vbounds! is for. The delta_multiplier is the correction factor which is applied to the specified bounds if a unit has configuration=DELTA.

👀 Reading hidden code
284 μs
add_unit_vbounds!(
data_eng,
lb_pu = 0.91,
ub_pu = 1.09,
delta_multiplier = sqrt(3),
unit_comp_types = ["load"]
)
👀 Reading hidden code
120 ms

Whether it is desirable to apply as many bounds as possible, or only ones which are really needed, will depend on the chosen formulation later on. In any case, these data model transformations make it easy for the user to apply any desired bounds.

You might have noticed that applying all these transformations will lead to redundant constraints. This is resolved in the data model transformation, which determines a set of absolute and pairwise voltage constraints which imply all other ones.

👀 Reading hidden code
400 μs

For example, let's inspect bus b2. This bus has a single-phase load between terminals 1 and 4, so only for that pair the lower bound should be 0.91 instead of 0.9. For an explanation of how the vm_pair_lb property is structured, refer to the documentation.

👀 Reading hidden code
335 μs
begin
data_math_tmp = transform_data_model(data_eng, kron_reduce=false, phase_project=false)
b2_index = data_math_tmp["bus_lookup"]["b2"]
b2_math = data_math_tmp["bus"]["$b2_index"]
b2_math["vm_pair_lb"]
end
👀 Reading hidden code
7.3 s

Adding a generator

So far, we imported a power flow case from OpenDSS. This means that the problem is fully determined, i.e. there is no degrees of freedom left over which to optimize. Therefore, we will add an additional generator to the problem.

👀 Reading hidden code
325 μs
begin
data_eng["generator"] = Dict{String,Any}()
data_eng["generator"]["g1"] = Dict{String,Any}(
"status" => ENABLED,
"bus" => "b2",
"configuration" => WYE,
"connections" => [2,4],
"pg_lb" => [0.0],
"pg_ub" => [20.0],
"qg_lb" => [0.0],
"qg_ub" => [0.0],
"cost_pg_parameters" => fill(0.0, 3)
)
end
👀 Reading hidden code
80.5 μs

Solving a simple OPF problem

👀 Reading hidden code
210 μs

First of all, we apply the data model transformation to go from ENGINEERING to MATHEMATICAL. We need to pass the flags kron_reduce=false and phase_project=false; these activate data model modifications which are required for some formulations, but not for the EN ones.

👀 Reading hidden code
257 μs
data_math = transform_data_model(data_eng, kron_reduce=false, phase_project=false)
👀 Reading hidden code
updated generator 1 cost function with order 3 to a function of order 2: [0.0, 0.0]
51.1 ms

Before solving the problem, it is important to add initialization values for the voltage variables. Failing to do so will almost always result in solver issues.

For a single-phase equivalent network, this is very easy to do; simply initialize each voltage variable to 1.0+im*0.0, which usually corresponds to the voltage profile without any network load and ignoring all shunts and linecharging (refered to as 'no-load voltage').

The equivalent in a general, multi-conductor network is less trivial; for example, transformers can shift the no-load voltage angle of individual terminals in complicated ways. Therefore, we developed the method add_start_vrvi!, which will infer the no-load voltage for each terminal in the network and add initialization properties to the MATHEMATICAL data model.

👀 Reading hidden code
429 μs
add_start_vrvi!(data_math)
👀 Reading hidden code
830 ms

Now we are ready to solve the OPF problem.

👀 Reading hidden code
244 μs
res = solve_mc_opf(data_math, IVRENPowerModel, Ipopt.Optimizer)
👀 Reading hidden code
❔
15.4 s

Inspecting results

👀 Reading hidden code
211 μs

The result dictionary contains the solutions.

👀 Reading hidden code
264 μs
sol_math = res["solution"]
👀 Reading hidden code
12.7 μs

Next, we can transform the solution back to the ENGINEERING data model.

👀 Reading hidden code
231 μs
sol_eng = transform_solution(sol_math, data_math)
👀 Reading hidden code
587 ms

For example, let's inspect the dispatched active generator power.

👀 Reading hidden code
237 μs
sol_eng["generator"]["g1"]["pg"]
👀 Reading hidden code
17.3 μs

The active generator power bounds are not active, 0 <= 5.15 <= 10.0. This means some other bound is active, because since g1 has zero cost, it is normally more optimal to dispatch more active power, which the source bus generator will pay the default price for.

So, let's inspect the voltage at bus b2. We will obtain the voltage base through 'data_math', which will allow us to inspect the voltage in pu. This will make it easier to compare the values against the bounds we specified before.

👀 Reading hidden code
15.3 ms
begin
vbase_b2 = data_math["bus"][string(data_math["bus_lookup"]["b1"])]["vbase"]
v_b2_pu = (sol_eng["bus"]["b2"]["vr"]+im*sol_eng["bus"]["b2"]["vi"])./vbase_b2
vm_b2_pu = abs.(v_b2_pu)
end
👀 Reading hidden code
333 ms

As it turns out, the voltage magnitude constraint om the neutral terminal is binding, vm_b2_pu[4]=0.1.

👀 Reading hidden code
260 μs

Available formulations

👀 Reading hidden code
198 μs

We create a results dictionary which will collect the result dictionary generated by each formulation. We will end this section with a comparison of them.

There are several formulations available which support explicit neutrals. The main distinction is whether the flow variables are current or power variables.

👀 Reading hidden code
277 μs
results = Dict{String,Any}()
👀 Reading hidden code
18.5 μs

Current flow variables

The preferred class of exact formulations are IVR, i.e. with current flow variables (I) and rectangular voltage variables (VR).

IVRENPowerModel is a non-linear formulation.

👀 Reading hidden code
317 μs
results["IVRENPowerModel"] = solve_mc_opf(data_math, IVRENPowerModel, Ipopt.Optimizer)
👀 Reading hidden code
❔
32.3 ms

IVRQuadraticENPowerModel is an equivalent quadratic formulation.

👀 Reading hidden code
224 μs
results["IVRQuadraticENPowerModel"] = solve_mc_opf(data_math, IVRQuadraticENPowerModel, Ipopt.Optimizer)
👀 Reading hidden code
❔
6.7 s

Reduced models only create explicit series current variables, and create the total current variables as linear expressions of those. Since branches tend to be the dominate component in number, this can lead to a big reduction in the number of variables.

IVRReducedENPowerModel is the branch-reduced version of IVRENPowerModel.

👀 Reading hidden code
365 μs
results["IVRReducedENPowerModel"] = solve_mc_opf(data_math, IVRReducedENPowerModel, Ipopt.Optimizer)
👀 Reading hidden code
❔
4.6 s

IVRReducedQuadraticENPowerModel is the branch-reduced version of IVRQuadraticENPowerModel.

👀 Reading hidden code
269 μs
results["IVRReducedQuadraticENPowerModel"] = solve_mc_opf(data_math, IVRReducedQuadraticENPowerModel, Ipopt.Optimizer)
👀 Reading hidden code
❔
4.6 s

Power flow variables

We also included a single formulation with power flow variables. Because it also has rectangular voltage variables and legacy reasons, it is referred to as ACRENPowerModel.

👀 Reading hidden code
318 μs
results["ACRENPowerModel"] = solve_mc_opf(data_math, ACRENPowerModel, Ipopt.Optimizer)
👀 Reading hidden code
❔
9.6 s

We mentioned before that the IVR formulations are preferred for EN models. This is because in EN models, the voltage magnitude cannot be bounded below for some terminals (the ones belonging to the neutral conductor). In a formulation with power flow variables, this means that KCL cannot be enforced for those terminals.

In short, ACR allows non-physical groundings of the neutral conductor, and is therefore a relaxation of the original problem. To demonstrate this, let's create a summary of the obtained objective values and generator set points.

👀 Reading hidden code
336 μs
formulationobjective valueg1 pg
StringFloat64Float64
1
"ACRENPowerModel"
-0.0169915
20.0
2
"IVRENPowerModel"
-0.00322506
5.07975
3
"IVRQuadraticENPowerModel"
-0.00322506
5.07975
4
"IVRReducedENPowerModel"
-0.00322506
5.07975
5
"IVRReducedQuadraticENPowerModel"
-0.00322506
5.07975
begin
forms = sort([keys(results)...])
sol_engs = Dict(f=>transform_solution(results[f]["solution"], data_math) for f in forms)
DataFrame(
"formulation" => forms,
"objective value" => [results[f]["objective"] for f in forms],
"g1 pg" => [sol_engs[f]["generator"]["g1"]["pg"][1] for f in forms],
)
end
👀 Reading hidden code
138 ms

This table illustrates that ACRENPowerModel is a relaxation of the other ones. However, note that it is not guaranteed that the objective value will be lower, because all problems are only solved to local optimality. And in fact, the ACR solution is very sensitive to changes in the initialization. The optional virtual groundings seem to introduce many potential local optima.

👀 Reading hidden code
284 μs